| 1 | import type { ChatMessage } from "@g1t/contracts"; |
| 2 | import { Stack, useLocalSearchParams } from "expo-router"; |
| 3 | import * as WebBrowser from "expo-web-browser"; |
| 4 | import { SendHorizontal } from "lucide-react-native"; |
| 5 | import { useEffect, useMemo, useState } from "react"; |
| 6 | import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | |
| 9 | import { Avatar, Badge, ErrorView, Loading, Screen, Text } from "@/components/ui"; |
| 10 | import { markRead, useChannelLive, useChatSidebar, useMessages, usePostMessage } from "@/lib/chat"; |
| 11 | import { siteUrl } from "@/lib/server"; |
| 12 | import { useSignedIn } from "@/lib/session"; |
| 13 | import { clock } from "@/lib/time"; |
| 14 | import { radius, space, useColors } from "@/theme"; |
| 15 | |
| 16 | /** |
| 17 | * A conversation: its messages, newest at the bottom, live while open, and |
| 18 | * a box to write in. `channel` is a channel's id, or a channel's name when |
| 19 | * a link named it (`/acme/-/chat/general`). |
| 20 | */ |
| 21 | export default function Conversation() { |
| 22 | const params = useLocalSearchParams<{ channel: string; title?: string }>(); |
| 23 | const { client, workspace, server } = useSignedIn(); |
| 24 | const sidebar = useChatSidebar(); |
| 25 | const entry = sidebar.data?.entries.find((one) => one.channel.id === params.channel || one.channel.name === params.channel); |
| 26 | // A link by name waits for the sidebar to say which channel it is. |
| 27 | const channelId = entry?.channel.id ?? (params.channel.includes("_") ? params.channel : ""); |
| 28 | const title = params.title ?? (entry ? (entry.channel.kind === "dm" ? entry.title : `#${entry.channel.name}`) : "Chat"); |
| 29 | |
| 30 | const messages = useMessages(channelId); |
| 31 | const live = useChannelLive(channelId); |
| 32 | const post = usePostMessage(channelId); |
| 33 | const [draft, setDraft] = useState(""); |
| 34 | const colors = useColors(); |
| 35 | const insets = useSafeAreaInsets(); |
| 36 | |
| 37 | // Newest first, for a list drawn from the bottom up. |
| 38 | const newestFirst = useMemo( |
| 39 | () => (messages.data?.pages ?? []).flatMap((page) => [...page.messages].reverse()).filter((message) => !message.deleted_at), |
| 40 | [messages.data], |
| 41 | ); |
| 42 | const newest = newestFirst[0]?.id; |
| 43 | useEffect(() => { |
| 44 | if (workspace && channelId && newest) void markRead(client, workspace.slug, channelId, newest); |
| 45 | }, [client, workspace, channelId, newest]); |
| 46 | |
| 47 | const send = () => { |
| 48 | const body = draft.trim(); |
| 49 | if (!body || post.isPending) return; |
| 50 | post.mutate(body, { onSuccess: () => setDraft("") }); |
| 51 | }; |
| 52 | |
| 53 | return ( |
| 54 | <Screen> |
| 55 | <Stack.Screen options={{ title, headerRight: () => <LiveDot status={live} /> }} /> |
| 56 | <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined} keyboardVerticalOffset={90}> |
| 57 | {!channelId && sidebar.isPending ? ( |
| 58 | <Loading /> |
| 59 | ) : messages.isPending ? ( |
| 60 | <Loading /> |
| 61 | ) : messages.error ? ( |
| 62 | <ErrorView error={messages.error} retry={() => messages.refetch()} /> |
| 63 | ) : ( |
| 64 | <FlatList |
| 65 | inverted |
| 66 | data={newestFirst} |
| 67 | keyExtractor={(message) => message.id} |
| 68 | renderItem={({ item }) => <Message message={item} site={server.site} />} |
| 69 | onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()} |
| 70 | onEndReachedThreshold={0.5} |
| 71 | ListFooterComponent={messages.isFetchingNextPage ? <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} /> : null} |
| 72 | contentContainerStyle={{ paddingVertical: space.md }} |
| 73 | /> |
| 74 | )} |
| 75 | <View style={[styles.composer, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: Math.max(insets.bottom, space.sm) }]}> |
| 76 | <TextInput |
| 77 | value={draft} |
| 78 | onChangeText={setDraft} |
| 79 | placeholder={`Message ${title}`} |
| 80 | placeholderTextColor={colors.faint} |
| 81 | multiline |
| 82 | style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]} |
| 83 | /> |
| 84 | <Pressable accessibilityRole="button" accessibilityLabel="Send" onPress={send} disabled={!draft.trim() || post.isPending} style={styles.send}> |
| 85 | {post.isPending ? <ActivityIndicator color={colors.accent} /> : <SendHorizontal color={draft.trim() ? colors.accent : colors.faint} size={22} />} |
| 86 | </Pressable> |
| 87 | </View> |
| 88 | {post.error ? ( |
| 89 | <Text tone="danger" size="small" style={{ paddingHorizontal: space.lg, paddingBottom: space.sm }}> |
| 90 | {post.error.message} |
| 91 | </Text> |
| 92 | ) : null} |
| 93 | </KeyboardAvoidingView> |
| 94 | </Screen> |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | function Message({ message, site }: { message: ChatMessage; site: string }) { |
| 99 | const colors = useColors(); |
| 100 | const { author } = message; |
| 101 | const card = message.kind === "card" ? message.card : null; |
| 102 | return ( |
| 103 | <View style={styles.message}> |
| 104 | <Avatar name={author.display_name} uri={author.avatar ? `${site}/avatars/${author.avatar}` : null} agent={author.kind === "agent"} /> |
| 105 | <View style={{ flex: 1, gap: 2 }}> |
| 106 | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "baseline" }}> |
| 107 | <Text style={{ fontWeight: "600" }}>{author.display_name}</Text> |
| 108 | <Text size="small" tone="faint"> |
| 109 | {clock(message.created_at)} |
| 110 | {message.edited_at ? " · edited" : ""} |
| 111 | </Text> |
| 112 | </View> |
| 113 | {message.body ? <Text selectable>{message.body}</Text> : null} |
| 114 | {card ? ( |
| 115 | <Pressable |
| 116 | disabled={!card.href} |
| 117 | onPress={() => card.href && WebBrowser.openBrowserAsync(siteUrl({ site, api: "" }, card.href))} |
| 118 | style={[styles.card, { borderColor: colors.lineStrong, backgroundColor: colors.surface }]} |
| 119 | > |
| 120 | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}> |
| 121 | <Text style={{ flex: 1, fontWeight: "600" }}>{card.title}</Text> |
| 122 | {card.state ? <Badge label={card.state} /> : null} |
| 123 | </View> |
| 124 | {card.detail ? ( |
| 125 | <Text size="small" tone="muted"> |
| 126 | {card.detail} |
| 127 | </Text> |
| 128 | ) : null} |
| 129 | {card.body ? ( |
| 130 | <Text size="small" numberOfLines={6}> |
| 131 | {card.body} |
| 132 | </Text> |
| 133 | ) : null} |
| 134 | </Pressable> |
| 135 | ) : null} |
| 136 | {message.reply_count > 0 ? ( |
| 137 | <Text size="small" tone="accent"> |
| 138 | {message.reply_count === 1 ? "1 reply" : `${message.reply_count} replies`} |
| 139 | </Text> |
| 140 | ) : null} |
| 141 | </View> |
| 142 | </View> |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | function LiveDot({ status }: { status: "connecting" | "live" | "offline" }) { |
| 147 | const colors = useColors(); |
| 148 | const color = status === "live" ? colors.success : status === "connecting" ? colors.warn : colors.faint; |
| 149 | return <View accessibilityLabel={status === "live" ? "Live" : status === "connecting" ? "Connecting" : "Offline"} style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />; |
| 150 | } |
| 151 | |
| 152 | const styles = StyleSheet.create({ |
| 153 | message: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.sm }, |
| 154 | card: { borderWidth: 1, borderRadius: radius.md, padding: space.md, gap: space.xs, marginTop: space.xs }, |
| 155 | composer: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth }, |
| 156 | input: { flex: 1, minHeight: 40, maxHeight: 140, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingVertical: space.sm, fontSize: 15 }, |
| 157 | send: { width: 40, height: 40, alignItems: "center", justifyContent: "center" }, |
| 158 | }); |